<?php

namespace App\Support\Services\Storage;

use Aliyun\OSS\Exceptions\OSSException;
use Aliyun\OSS\Models\OSSOptions;
use Aliyun\OSS\OSSClient;
use Carbon\Carbon;
use App\Contracts\Services\Storage\StorageService as StorageServiceContract;
use App\Support\Helpers\StringHelper;

class AliOSSService implements StorageServiceContract
{
    const SCHEMA = 'alioss://';

    /**
     * OSS Client
     * @var \Aliyun\OSS\OSSClient
     */
    private $client;

    /**
     * default bucket name
     * @var string
     */
    private $bucket;

    /**
     * url schema
     * @var string
     */
    private $schema;

    /**
     * base portal url
     * @var string
     */
    private $baseUrl;

    /**
     *
     * @param OSSClient $client    client to communicat with AliOSS server
     * @param array $config        configurations.
     *                             - bucket   default buck name (defaults to "default")
     */
    public function __construct(OSSClient $client, array $config)
    {
        $this->client  = $client;
        $this->bucket  = array_get($config, 'bucket', 'default');
        $this->schema  = array_get($config, 'schema', self::SCHEMA);
        $this->baseUrl = rtrim(array_get($config, 'base_url'), '/');
    }

    /**
     * {@inheritdoc}
     */
    public function store($file, array $options = [])
    {
        $identifier = $this->createFileIdentifier($file, $options);

        // putObject() returns an instance of \Aliyun\OSS\Models\PutObjectResult, which
        // contains the eTag of the file
        try {
            $result = $this->client->putObject([
                OSSOptions::BUCKET => $this->getBucket(array_get($options, 'bucket')),
                OSSOptions::KEY => $identifier,
                OSSOptions::CONTENT => fopen($file, 'r'),
                OSSOptions::CONTENT_LENGTH => filesize($file),
                OSSOptions::CONTENT_TYPE => $this->guessMime($file, $options)
            ]);
        } catch (OSSException $ex) {
            throw new \InvalidArgumentException($ex->getMessage());
        }

        // all info is requested to be returned
        if (array_get($options, 'url_only', true)) {
            return $this->schema . $identifier;
        }

        /** @var $result \Aliyun\OSS\Models\CopyObjectResult */
        return [
            'id'   => $this->schema . $identifier,
            'etag' => $result->getETag(),
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function resolve($url, array $options = [])
    {
        $identifier = $this->extractIdentifier($url);

        try {
            /* @var $object \Aliyun\OSS\Models\OSSObject */
            $object = $this->client->getObject([
                OSSOptions::BUCKET => $this->getBucket(array_get($options, 'bucket')),
                OSSOptions::KEY    => $identifier,
            ]);

            // get metadata if requested
            $metadata = null;
            if (array_get($options, 'metadata', false)) {
                $metadata = $object->getMetadata();
            }

            // get content
            if (array_get($options, 'content', true)) {
                $content = $object->getObjectContent();

                if (!$metadata) {       // no metadata (content only),
                    return $content;    // return content as stream
                }

                // both content and metadata are requested
                return [
                    'content' => $content,
                    'metadata' => $metadata,
                ];
            } else {
                if ($metadata) {  // metadata only
                    return $metadata;
                }

                throw new \InvalidArgumentException('content and metadata cannot be false meanwhile');
            }
        } catch (OSSException $ex) {
            // if no such key in OSS, suppress the exception
            if ($ex->getErrorCode() == 'NoSuchKey') {
                if (array_get($options, 'null_for_nonexistence', true)) {
                    return null;
                }
            }

            throw $ex;
        }
    }

    /**
     * {@inheritdoc}
     */
    public function remove($url, array $options = [])
    {
        $identifier = $this->extractIdentifier($url);

        if (array_get($options, 'inspect') &&
            !$this->has($identifier, [ 'bucket' => array_get($options, 'bucket') ])) {
            throw new \Exception('no such file');
        }

        // deleteObject() always succeeds, even if the object to be deleted does not exist
        $this->client->deleteObject([
            OSSOptions::BUCKET => $this->getBucket(array_get($options, 'bucket')),
            OSSOptions::KEY    => $identifier,
        ]);
    }

    /**
     * {@inheritdoc}
     */
    public function toHttpUrl($url, array $options = [])
    {
        if (!$this->hasSchema($url)) {
            return $url;
        }
        $identifier = $this->extractIdentifier($url);

        if (array_get($options, 'inspect') &&
            !$this->has($identifier, [ 'bucket' => array_get($options, 'bucket') ])) {
            return $identifier;
        }

        return $this->baseUrl . '/' . $identifier;
    }

    /**
     * {@inheritdoc}
     */
    public function toSchemaUrl($url, array $options = [])
    {
        if (!starts_with($url, $this->baseUrl)) {
            throw new \InvalidArgumentException('Not http url: ' . $url);
        }
        $identifier = ltrim(str_replace($this->baseUrl, '', $url), '/');

        if (array_get($options, 'inspect') &&
            !$this->has($identifier, [ 'bucket' => array_get($options, 'bucket') ])) {
            throw new \Exception('no such file');
        }

        return $identifier;
    }

    /**
     * {@inheritdoc}
     */
    public function copy($source, array $options = [])
    {
        $identifier = $this->createFileIdentifier($source, $options);

        $result = $this->client->copyObject([
            OSSOptions::SOURCE_BUCKET => $this->getBucket(array_get($options, 'source_bucket')),
            OSSOptions::SOURCE_KEY    => $source,
            OSSOptions::DEST_BUCKET   => $this->getBucket(array_get($options, 'dest_bucket')),
            OSSOptions::DEST_KEY      => $identifier,
        ]);

        // all info is requested to be returned
        if (array_get($options, 'url_only', true)) {
            return $this->schema . $identifier;
        }

        /** @var $result \Aliyun\OSS\Models\CopyObjectResult */
        return [
            'id'   => $this->schema . $identifier,
            'etag' => $result->getETag(),
        ];
    }

    private function guessMime($file, $options = [])
    {
        if ($mime = array_get($options, 'mime')) {
            return $mime;
        }

        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime = finfo_file($finfo, $file);
        finfo_close($finfo);

        $mimeMapping = [
            'css' => 'text/css',
            'js'  => 'text/javascript',
        ];

        if ('text/plain' == $mime) {
            $ext = pathinfo($file, PATHINFO_EXTENSION);
            if (isset($mimeMapping[$ext])) {
                $mime = $mimeMapping[$ext];
            }
        }

        return $mime;
    }

    private function has($identifier, $bucket = null)
    {
        try {
            $this->client->getObjectMetadata([
                OSSOptions::BUCKET => $this->getBucket($bucket),
                OSSOptions::KEY    => $identifier,
            ]);

            return true;
        } catch (OSSException $ex) {
            // if no such key in OSS, suppress the exception
            if ($ex->getErrorCode() == 'NoSuchKey') {
                return false;
            }

            throw $ex;
        }
    }

    // get bucket, use defualt bucket if given bucket is empty
    private function getBucket($bucket = null)
    {
        return $bucket ?: $this->bucket;
    }

    // create identity for a file to be uploaded
    private function createFileIdentifier($file, $options)
    {
        // check whether object identifier is provided or not
        if (array_key_exists('url', $options)) {
            return $this->extractIdentifier($options['url']);
        }

        // find file extension
        $ext = $this->guessFileFormat($file, $options);
        // sane extension to ensure it starts with '.'.
        $ext  = $ext ? ('.' . ltrim($ext, '.')) : $ext;

        return implode('/', array_filter([
            trim(array_get($options, 'prefix'), '/'),
            $this->generateUniqueFilename($file, $ext),
        ]));
    }

    private function guessFileFormat($file, array $options)
    {
        $ext = array_key_exists('ext', $options) ? $options['ext']
                                                 : pathinfo($file, PATHINFO_EXTENSION);
        if ($ext) {
            return $ext;
        }

        $mime = $this->guessMime($file);

        return $this->mapFileFormat($mime);
    }

    private function mapFileFormat($mime)
    {
        $parts = explode('/', $mime);

        // we could map it, but it's not that necessary for current
        // simply return the second part
        return $parts[1];
    }

    private function generateUniqueFilename($file, $ext)
    {
        return substr(Carbon::now()->format('YmdHisu'), 0, -6) .  // resolution in microsecond. date('u') suffixes
                                                                  // with 6 zeros, so eliminate it
               StringHelper::quickRandom(6, '0123456789') .  // 6 random numbers
               $ext;
    }

    private function extractIdentifier($url)
    {
        //        if (!$this->hasSchema($url)) {
        //            throw new \InvalidArgumentException('Alioss don\'t support this url: ' . $url);
        //        }

        return substr($url, mb_strlen($this->schema)); // remove the schema to find the identifier
    }

    private function hasSchema($url)
    {
        return starts_with($url, $this->schema);
    }
}
